package io.kiki.stack.http.feign;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.logging.FileHandler;
import java.util.logging.LogRecord;
import java.util.logging.SimpleFormatter;

import static io.kiki.stack.http.feign.Util.*;
import static java.util.Objects.nonNull;

/**
 * Simple logging abstraction for debug messages. Adapted from {@code retrofit.RestAdapter.Log}.
 */
public abstract class Logger {

    protected static String methodTag(String configKey) {
        return '[' + configKey.substring(0, configKey.indexOf('(')) + "] ";
    }

    protected static String resolveProtocolVersion(Request.ProtocolVersion protocolVersion) {
        if (nonNull(protocolVersion)) {
            return protocolVersion.toString();
        }
        return "UNKNOWN";
    }

    /**
     * Override to log requests and responses using your own implementation. Messages will be http
     * request and response text.
     *
     * @param configKey value of {@link Feign#configKey(Class, java.lang.reflect.Method)}
     * @param format    {@link java.util.Formatter format string}
     * @param args      arguments applied to {@code format}
     */
    protected abstract void log(String configKey, String format, Object... args);

    /**
     * Override to filter out request headers.
     *
     * @param header header name
     * @return true to log a request header
     */
    protected boolean shouldLogRequestHeader(String header) {
        return true;
    }

    /**
     * Override to filter out response headers.
     *
     * @param header header name
     * @return true to log a response header
     */
    protected boolean shouldLogResponseHeader(String header) {
        return true;
    }

    protected void logRequest(String configKey, Level logLevel, Request request) {
        String protocolVersion = resolveProtocolVersion(request.protocolVersion());
        log(configKey, "---> %s %s %s", request.httpMethod().name(), request.url(), protocolVersion);
        if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {

            for (String field : request.headers().keySet()) {
                if (shouldLogRequestHeader(field)) {
                    for (String value : valuesOrEmpty(request.headers(), field)) {
                        log(configKey, "%s: %s", field, value);
                    }
                }
            }

            int bodyLength = 0;
            if (request.body() != null) {
                bodyLength = request.length();
                if (logLevel.ordinal() >= Level.FULL.ordinal()) {
                    String bodyText = request.charset() != null ? new String(request.body(), request.charset()) : null;
                    log(configKey, ""); // CRLF
                    log(configKey, "%s", bodyText != null ? bodyText : "Binary data");
                }
            }
            log(configKey, "---> END HTTP (%s-byte body)", bodyLength);
        }
    }

    protected void logRetry(String configKey, Level logLevel) {
        log(configKey, "---> RETRYING");
    }

    protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException {
        String protocolVersion = resolveProtocolVersion(response.protocolVersion());
        String reason = response.reason() != null && logLevel.compareTo(Level.NONE) > 0 ? " " + response.reason() : "";
        int status = response.status();
        log(configKey, "<--- %s %s%s (%sms)", protocolVersion, status, reason, elapsedTime);
        if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {

            for (String field : response.headers().keySet()) {
                if (shouldLogResponseHeader(field)) {
                    for (String value : valuesOrEmpty(response.headers(), field)) {
                        log(configKey, "%s: %s", field, value);
                    }
                }
            }

            int bodyLength = 0;
            if (response.body() != null && !(status == 204 || status == 205)) {
                // HTTP 204 No Content "...response MUST NOT include a message-body"
                // HTTP 205 Reset Content "...response MUST NOT include an entity"
                if (logLevel.ordinal() >= Level.FULL.ordinal()) {
                    log(configKey, ""); // CRLF
                }
                byte[] bodyData = Util.toByteArray(response.body().asInputStream());
                ensureClosed(response.body());
                bodyLength = bodyData.length;
                if (logLevel.ordinal() >= Level.FULL.ordinal() && bodyLength > 0) {
                    log(configKey, "%s", decodeOrDefault(bodyData, UTF_8, "Binary data"));
                }
                log(configKey, "<--- END HTTP (%s-byte body)", bodyLength);
                return response.toBuilder().body(bodyData).build();
            } else {
                log(configKey, "<--- END HTTP (%s-byte body)", bodyLength);
            }
        }
        return response;
    }

    protected IOException logIOException(String configKey, Level logLevel, IOException ioe, long elapsedTime) {
        log(configKey, "<--- ERROR %s: %s (%sms)", ioe.getClass().getSimpleName(), ioe.getMessage(), elapsedTime);
        if (logLevel.ordinal() >= Level.FULL.ordinal()) {
            StringWriter sw = new StringWriter();
            ioe.printStackTrace(new PrintWriter(sw));
            log(configKey, "%s", sw.toString());
            log(configKey, "<--- END ERROR");
        }
        return ioe;
    }

    /**
     * Controls the level of logging.
     */
    public enum Level {
        /**
         * No logging.
         */
        NONE,
        /**
         * Log only the request method and URL and the response status code and execution time.
         */
        BASIC,
        /**
         * Log the basic information along with request and response headers.
         */
        HEADERS,
        /**
         * Log the headers, body, and metadata for both requests and responses.
         */
        FULL
    }

    /**
     * Logs to System.err.
     */
    public static class ErrorLogger extends Logger {
        @Override
        protected void log(String configKey, String format, Object... args) {
            System.err.printf(methodTag(configKey) + format + "%n", args);
        }
    }

    /**
     * Logs to the category {@link Logger} at {@link java.util.logging.Level#FINE}, if loggable.
     */
    public static class JavaLogger extends Logger {

        final java.util.logging.Logger logger;

        /**
         * @deprecated Use {@link #JavaLogger(String)} or {@link #JavaLogger(Class)} instead.
         * <p>This constructor can be used to create just one logger. Example = {@code
         * Logger.JavaLogger().appendToFile("logs/first.log")}
         * <p>If you create multiple loggers for multiple clients and provide different files to
         * write log - you'll have unexpected behavior - all clients will write same log to each
         * file.
         * <p>That's why this constructor will be removed in future.
         */
        @Deprecated
        public JavaLogger() {
            logger = java.util.logging.Logger.getLogger(Logger.class.getName());
        }

        /**
         * Constructor for JavaLogger class
         *
         * @param loggerName a name for the logger. This should be a dot-separated name and should
         *                   normally be based on the package name or class name of the subsystem, such as java.net or
         *                   javax.swing
         */
        public JavaLogger(String loggerName) {
            logger = java.util.logging.Logger.getLogger(loggerName);
        }

        /**
         * Constructor for JavaLogger class
         *
         * @param clazz the returned logger will be named after clazz
         */
        public JavaLogger(Class<?> clazz) {
            logger = java.util.logging.Logger.getLogger(clazz.getName());
        }

        @Override
        protected void logRequest(String configKey, Level logLevel, Request request) {
            if (logger.isLoggable(java.util.logging.Level.FINE)) {
                super.logRequest(configKey, logLevel, request);
            }
        }

        @Override
        protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException {
            if (logger.isLoggable(java.util.logging.Level.FINE)) {
                return super.logAndRebufferResponse(configKey, logLevel, response, elapsedTime);
            }
            return response;
        }

        @Override
        protected void log(String configKey, String format, Object... args) {
            if (logger.isLoggable(java.util.logging.Level.FINE)) {
                logger.fine(String.format(methodTag(configKey) + format, args));
            }
        }

        /**
         * Helper that configures java.util.logging to sanely log messages at FINE level without
         * additional formatting.
         */
        public JavaLogger appendToFile(String logfile) {
            logger.setLevel(java.util.logging.Level.FINE);
            try {
                FileHandler handler = new FileHandler(logfile, true);
                handler.setFormatter(new SimpleFormatter() {
                    @Override
                    public String format(LogRecord record) {
                        return String.format("%s%n", record.getMessage()); // NOPMD
                    }
                });
                logger.addHandler(handler);
            } catch (IOException e) {
                throw new IllegalStateException("Could not add file handler.", e);
            }
            return this;
        }
    }

    public static class NoOpLogger extends Logger {

        @Override
        protected void logRequest(String configKey, Level logLevel, Request request) {
        }

        @Override
        protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException {
            return response;
        }

        @Override
        protected void log(String configKey, String format, Object... args) {
        }
    }
}
